compiler: pass large aggregate parameters indirectly (#5615) - #5618
compiler: pass large aggregate parameters indirectly (#5615)#5618neomantra wants to merge 7 commits into
Conversation
Signed-off-by: Evan Wies <evan@neomantra.net>
LLVM's WebAssembly backend flattens aggregate parameters into individual
scalar parameters, and the WebAssembly JS embedding rejects function
types with more than 1000 parameters. Large value structs (for example
lipgloss.Style with ~95 scalar leaves, or list.Model which contains ~40
of them) therefore produced functions with thousands of wasm parameters
that browsers refuse to instantiate:
argument count of Type ... is too big 3730 maximum 1000
With this change, parameters with more than 16 flattened scalar leaves
(arrays counted per element) are passed by pointer to a caller-owned
copy in the Go-internal calling convention: the caller stores the value
into a temporary alloca and passes the pointer, and the callee loads it
once at function entry. The pointer is non-null, read-only, properly
aligned, and does not escape, and is annotated accordingly.
Exported and external functions (//export, cgo, C ABI) deliberately
keep the previous behavior, selected via a new abiKind threaded through
the shared parameter expansion helpers. Deferred calls inherit the new
convention automatically because defer stores unexpanded values and
re-issues the call through createCall at rundefers time.
Signed-off-by: Evan Wies <evan@neomantra.net>
The invoke wrapper unpacks the receiver from the interface box and calls the real method, so a >16-leaf value receiver must be re-spilled to match the wrapped signature. Pass the raw receiver to createCallABI with the ABI of the wrapped function: Go-ABI methods get the spill, while exported (C ABI) methods keep by-value receiver expansion, also in their wrapper. Signed-off-by: Evan Wies <evan@neomantra.net>
Pins the new convention: the 16/17-leaf boundary, array leaf counting, the unchanged C ABI of exported functions (including methods invoked through an interface), caller-side spill allocas, the heap spill for go statements, and the interface invoke wrapper. This also changes the golden test harness to run instcombine with no-verify-fixpoint, affecting all golden tests: standalone textual instcombine fatally aborts when it needs more than one iteration (an LLVM 18+ testing aid), which the interface-boxing code in this test triggers. Real pass pipelines (see transform/optimizer.go) already run instcombine with no-verify-fixpoint, falling back to plain instcombine before LLVM 18 where the flag does not exist; the harness mirrors that. All other golden files produce byte-identical output. Signed-off-by: Evan Wies <evan@neomantra.net>
Checks value semantics and GC safety of by-pointer passing of large aggregate parameters across all call paths: direct calls, func values, interface method calls (kept dynamic with a second implementation), defer (capturing the value at defer time), and goroutines (where the spilled copy must outlive the spawning frame). The callee forces allocation churn and collections before dereferencing the spilled copy's pointer fields. Skipped on AVR like gc.go, for the same conservative-GC flakiness. Signed-off-by: Evan Wies <evan@neomantra.net>
Resolving a phi may need to materialize storage for an incoming value:
a constant or a register-form load has no backing allocation yet, so
the phi edge emits an alloc and a copy. These were emitted at the
builder's current position, after the function body, appending them
behind the last block's terminator and producing malformed IR ("Basic
Block does not have terminator") in real bubbles/list functions once
the 16-leaf threshold made mid-size aggregates spill. Emit each edge's
materialization in its predecessor block, before that block's
terminator. The golden test pins both shapes: an if/else merge and a
loop back edge, each with a constant and a plain-load incoming value.
Signed-off-by: Evan Wies <evan@neomantra.net>
Passing aggregate parameters through backing storage makes call-heavy code use more stack: spill copies up to 1024 bytes are promoted from the heap to the stack by the allocation optimizer, and without lifetime markers each spilled call site keeps its own frame slot. A real Bubble Tea list application (bubbles/list with go-booba browser I/O) overflows the previous 64KB default at startup; measurement brackets its need between 64KB and 96KB. Double the default on the wasm, wasip1, and wasip2 targets, which is where large Go applications like TUIs run. Regenerate the goldens that embed the goroutine stack size constant. Signed-off-by: Evan Wies <evan@neomantra.net>
|
I'm not sure I like this fix (seems a bit too AI-special-case-y); I'm looking into this problem separately just to convince myself of that, though. |
|
Thank you for your patience in reviewing. While I can understand code diffs, I have limited experience in compiler/LLVM domain. Chopping and shaping trees, without traveling the forest. It did seem natural to me to extend spilling to more circumstances and I only really worked to release the PR after the other spilling work (it was a much more complicated changeset last month). I pushed on this further since yesterday, particularly I didn't like that we still had to bump the stack size ( If there's anything you want me to explore on this, I'm happy to put the energy in to advance it, while trying to be considerate of the review/integration effort. |
As described in #5615, we need to spill aggregate parameters. This has been worked over and reviewed by humans and LLMs. I tested it against some BubbleTea programs, as well as the tests described below. I finally got BubbleTea Lists working with WASM, but there's another issue that needs resolved.
The below is LLM-generated. I have read and reviewed it as well as the code.
Summary
Pass aggregate parameters with more than 16 scalar leaves through backing storage in the Go-internal ABI, so each parameter contributes at most 16 scalars after WebAssembly scalarization. This is a practical mitigation of the JS embedding's 1,000-parameter limit rather than whole-signature enforcement: exceeding the cap now requires a function with more than 62 parameters, far beyond any signature observed in practice (the worst real Bubble Tea offender was a single receiver with thousands of leaves). Exported/C-ABI signatures are deliberately unchanged — their ABI is externally visible and cannot be respilled — so an exported function with huge aggregate parameters can still exceed the cap.
Fixes #5615.
Problem
LLVM's WebAssembly backend recursively scalarizes struct-by-value parameters. A single
lipgloss.Stylehas roughly 95 scalar leaves, and value receivers such asbubbles/list.Modelcontain many Styles. BeforemaxDirectAggregateValues, real Bubble Tea functions produced wasm types with thousands of parameters (3730 / 4316 measured); browsers reject any function type above 1000 parameters, and wasmparser-based tooling (wasm-tools et al.) rejects such modules too.Current
devalready has a generalized indirect-aggregate engine (maxDirectAggregateValues = 1024, from #5526). That threshold was chosen to protect LLVM itself — SelectionDAG's 65,535-value representation limit and the compile-time cliff before it (#5477) — but it does not protect the stricter whole-function JavaScript embedding limit: several individually smaller aggregate parameters can still exceed 1000 in total. This PR extends #5526's approach (preemptive indirection before LLVM, exported types unchanged) with a lower threshold on the parameter side for that external limit. Measured ondev(f71b630 and current 86d58db): the bubbleslist-fancyexample's largest function type is at 995 params — five below the cap — and a 25-line program with two 600-leaf struct params (see the issue) emits a 1200-param type that wasm-tools refuses to parse.Implementation
dev— an exported method with a[1025]byteresult through an interface crashes dev's compiler with a slice-bounds panic ingetInterfaceInvokeWrapper. For parameters between 17 and 1024 leaves the error is a knowing trade-off: dev compiles and runs that narrow combination correctly (it doesn't spill below 1024), and restoring it under the lower threshold would require a full C/Go ABI bridge in the wrapper, which can be added separately if the combination matters in practice. The error path is covered by the compiler-errors test, whose harness now also fails on missing expected errors.bubbles/listfunctions ("Basic Block ... does not have terminator").//go:noinlinepicker soStaticCallee()is nil, and the golden pins the func-value decode (code-pointer extraction, nil check, indirect call with the spilled parameter). The phi golden pins realphi ptrnodes for both an if/else merge and a loop back edge, with incoming values (a constant and a plain load) that require predecessor materialization — reverting the phi placement fix makes this test fail with the same malformed-IR error seen in the real Bubble Tea build.Verification
go test -tags llvm22 ./compiler -run '^TestParamNeedsSpill$' -count=1 -vgo test -tags llvm22 ./compiler -run '^TestCompiler/paramspill.go$' -count=1 -vgo test -tags llvm22 ./compiler -run '^TestCompilerErrors$' -count=1 -v— includes the new exported-method-through-interface diagnostics; the strengthened harness fails if an expected// ERROR:is not produced.go test -tags llvm22 . -run '^TestBuild/Host/paramspill.go$' -short -count=1 -vgo test -tags llvm22 . -run '^TestBuild/WebAssembly/paramspill.go$' -count=1 -vgo test -tags llvm22 ./compiler -count=1[1025]byteresult called through an interface now fails with the clear diagnostic (the same program crashes currentdev's compiler); a supported exported method (17-leaf value receiver, small result) called through an interface returns correct values at runtime.examples/list-fancywithGOOS=js GOARCH=wasm: success with no Binaryen large-parameter warning.wasm-tools validate: success.maxDirectAggregateValues), safely below 1000.wasm-toolsparses and validates.All of the above were run against the branch head after the final rebase onto
dev@ 86d58db.Note on goroutine arguments
This PR deliberately contains no goroutine-specific spill code:
dev's existinggetGoroutineCallArgument+copyToIndirectStoragepath already gives spilled goroutine arguments a GC-safe heap lifetime, and the spill predicate plugs into it unchanged. The behavioral test still covers the goroutine path (a spilled argument surviving the spawner's return, under GC stress) to pin that reuse.